跳到主要内容

Canvas 优化学习

转载自 Canvas 性能优化 转载自 Canvas 动画的性能优化实践

使用 requestNextAnimationFrame 进行动画循环

setTimeout 和 setInterval并非是专为连续循环产生的 API,所以可能无法达到流畅的动画表现,故用 requestNextAnimationFrame,可能需要 polyfill:

const raf = window.requestAnimationFrame
|| window.webkitRequestAnimationFrame
|| window.mozRequestAnimationFrame
|| window.oRequestAnimationFrame
|| window.msRequestAnimationFrame
|| function(callback) {
window.setTimeout(callback, 1000 / 60)
}

使用多层画布绘制复杂场景

分层的目的是降低完全不必要的渲染性能开销。

如游戏中的背景绘制频率低可以放在一层canvas上,上面的小人等绘制频率高放在一层canvas上,两层canvas的叠加效果达到完整效果。

即:将变化频率高、幅度大的部分和变化频率小、幅度小的部分分成两个或两个以上的 canvas 对象。也就是说生成多个 canvas 实例,把它们重叠放置,每个 Canvas 使用不同的 z-index 来定义堆叠的次序。

<canvas style="position: absolute; z-index: 0"></canvas>
<canvas style="position: absolute; z-index: 1"></canvas>

离屏缓冲区(离屏 canvas)

当时用 drawImage 绘制同样的一块区域:

  • 若数据源(图片、canvas)和 canvas 画板的尺寸相仿,那么性能会比较好;
  • 若数据源只是大图上的一部分,那么性能就会比较差;因为每一次绘制还包含了裁剪工作。

第二种情况我们就可以先把待绘制的区域裁剪好,保存在一个离屏的 canvas 对象中。在绘制每一帧的时候,在将这个对象绘制到 canvas 画板中。

drawImage 方法的第一个参数不仅可以接收 Image 对象,也可以接收另一个 Canvas 对象。而且,使用 Canvas 对象绘制的开销与使用 Image 对象的开销几乎完全一致。

当每一帧需要调用的对象需要多次调用 canvasAPI 时,我们也可以使用离屏绘制进行预渲染的方式来提高性能。

即:

let cacheCanvas = document.createElement("canvas");
let cacheCtx = this.cacheCanvas.getContext("2d");

cacheCtx.save();
cacheCtx.lineWidth = 1;
for(let i = 1;i < 40; i++){
cacheCtx.beginPath();
cacheCtx.strokeStyle = this.color[i];
cacheCtx.arc(this.r , this.r , i , 0 , 2*Math.PI);
cacheCtx.stroke();
}

this.cacheCtx.restore();

// 在绘制每一帧的时候,绘制这个图形
context.drawImage(cacheCtx, x, y);

cacheCtx 的宽高尽量设置成实际使用的宽高,否则过多空白区域也会造成性能的损耗。

另外,离屏 canvas 不再使用时,最好把手动将引用重置为 null,避免因为 js 和 dom 之间存在的关联,导致垃圾回收机制无法正常工作,占用资源

尽量利用 CSS

背景图

如果有大的静态背景图,直接绘制到 canvas可能并不是一个很好的做法,如果可以,将这个大背景图作为 background-image 放在一个 DOM 元素上(例如,一个 div),然后将这个元素放到 canvas 后面,这样就少了一个 canvas 的绘制渲染

transform变幻

CSS 的 transform 性能优于 canvas 的 transfomr API,因为前者基于可以很好地利用 GPU,所以如果可以,transform 变幻请使用 CSS 来控制

尽量不要频繁地调用比较耗时的API

shadow 相关 API,此类 API 包括 shadowOffsetX、shadowOffsetY、shadowBlur、shadowColor

绘图相关的 API,例如 drawImage、putImageData,在绘制时进行缩放操作也会增加耗时时间

当然,上述都是尽量避免 频繁调用,或用其他手段来控制性能,需要用到的地方肯定还是要用的

避免浮点数的坐标

利用 canvas 进行动画绘制时,如果计算出来的坐标是浮点数,那么可能会出现 CSS Sub-pixel 的问题,也就是会自动将浮点数值四舍五入转为整数,那么在动画的过程中,由于元素实际运动的轨迹并不是严格按照计算公式得到,那么就可能出现抖动的情况,同时也可能让元素的边缘出现抗锯齿失真

注意:javascript 提供了一些取整方法,像 Math.floorMath.ceilparseInt,但 parseInt 这个方法做了一些额外的工作(比如检测数据是不是有效的数值、先将参数转换成了字符串等),所以,直接用 parseInt 的话相对来说比较消耗性能。

可以直接用以下巧妙的方法进行取整:

function getInt(num){
var rounded;
rounded = (0.5 + num) | 0;
return rounded;
}

这也是可能影响性能的一方面,因为一直在做不必要的取证运算

渲染绘制操作不要频繁调用

渲染绘制的 api,例如 stroke、fill、drawImage,都是将 ctx 状态机里面的状态真实绘制到画布上,这种操作也比较耗费性能

例如,如果你要绘制十条线段,那么先在 ctx 状态机中绘制出十天线段的状态机,再进行一次性的绘制,这将比每条线段都绘制一次要高效得多

for (let i = 0; i < 10; i++) {
context.beginPath()
context.moveTo(x1[i], y1[i])
context.lineTo(x2[i], y2[i])
// 每条线段都单独调用绘制操作,比较耗费性能
context.stroke()
}

for (let i = 0; i < 10; i++) {
context.beginPath()
context.moveTo(x1[i], y1[i])
context.lineTo(x2[i], y2[i])
}
// 先绘制一条包含多条线条的路径,最后再一次性绘制,可以得到更好的性能
context.stroke()

尽量少的改变状态机 ctx的里状态

ctx可以看做是一个状态机,例如 fillStyle、globalAlpha、beginPath,这些 api 都会改变 ctx里面对于的状态,频繁改变状态机的状态,是影响性能的

可以通过对操作进行更好的规划,减少状态机的改变,从而得到更加的性能,例如在一个画布上绘制几行文字,最上面和最下面文字的字体都是 30px,颜色都是 yellowgreen,中间文字是 20px pink,那么可以先绘制最上面和最下面的文字,再绘制中间的文字,而非必须从上往下依次绘制,因为前者减少了一次状态机的状态改变

const c = document.getElementById("myCanvas")
const ctx = c.getContext("2d")

ctx.font = '30 sans-serif'
ctx.fillStyle = 'yellowgreen'
ctx.fillText("大家好,我是最上面一行", 0, 40)

ctx.font = '20 sans-serif'
ctx.fillStyle = 'red'
ctx.fillText("大家好,我是中间一行", 0, 80)

ctx.font = '30 sans-serif'
ctx.fillStyle = 'yellowgreen'
ctx.fillText("大家好,我是最下面一行", 0, 130)

下面的代码实现的效果和上面相同,但是代码量更少,同时比上述代码少改变了一次状态机,性能会更好

ctx.font = '30 sans-serif'
ctx.fillStyle = 'yellowgreen'
ctx.fillText("大家好,我是最上面一行", 0, 40)
ctx.fillText("大家好,我是最下面一行", 0, 130)

ctx.font = '20 sans-serif'
ctx.fillStyle = 'red'
ctx.fillText("大家好,我是中间一行", 0, 80)

避免阻塞

所谓「阻塞」,可以理解为不间断运行时间超过 16ms 的 JavaScript 代码,导致页面卡顿,丢帧,或者失去响应,这种问题能很快被用户察觉到,造成很差的交互体验。

可以通过以下两种手段:

web worker

web worker最常用的场景就是大量的频繁计算,减轻主线程压力,如果遇到大规模的计算,可以通过此 API 分担主线程压力,此 API 兼容性已经很不错了,既然 canvas 可以用,那 web worker 也就完全可以考虑使用

像下图的效果,需要计算大量函数曲线上的点来绘制成曲线,我们移动的时候可以看到计算新点坐标值的过程是有延迟的,但是并不会让用户鼠标拖拽卡顿失效,渲染的过程再跟随鼠标移动。

分解任务

将一段大的任务过程分解成数个小型任务,使用定时器轮询进行,想要对一段任务进行分解操作,此任务需要满足以下情况:

  • 循环处理操作并不要求同步
  • 数据并不要求按照顺序处理

分解任务包括两种情形:

1、根据任务总量分配

例如进行一个千万级别的运算总任务,可以将其分解为 10个百万级别的运算小任务

// 封装 定时器分解任务 函数
function processArray(items, process, callback) {
// 复制一份数组副本
var todo=items.concat();
setTimeout(function(){
process(todo.shift());
if(todo.length>0) {
// 将当前正在执行的函数本身再次使用定时器
setTimeout(arguments.callee, 25);
} else {
callback(items);
}
}, 25);
}

var items=[12,34,65,2,4,76,235,24,9,90];

function outputValue(value) {
console.log(value);
}

// 使用
processArray(items, outputValue, function(){
console.log('Done!');
});

优点是任务分配模式比较简单,更有控制权,缺点是不好确定小任务的大小,有的小任务可能因为某些原因,会耗费比其他小任务更多的时间,这会造成线程阻塞;而有的小任务可能需要比其他任务少得多的时间,造成资源浪费

在 TypeScript 使用:

/**
* 根据任务总量分配
* 例如进行一个千万级别的运算总任务,可以将其分解为 10 个百万级别的运算小任务
*
* 封装 定时器分解任务 函数
* @param items 这个就是一个参数对象的队列,每个参数代表一次任务
* @param process 执行上面参数的回调函数
* @param callback 全部任务执行完成时调用的回调函数
*/
export const processArray = <T>(items: Array<T>, process: (item: T | undefined) => void, callback: (items: Array<T>) => void): void => {
// 复制一份数组副本,如果是实时调用的(例如拖动缓存),可以直接 const todo = items
const todo = items.concat();

// 如果是拖动时实时更新的,可以加个 if (todo.length < 3)
// 为了避免一直复制,这里应该使用内部函数的递归
setTimeout(function fun() {
// shift 方法,删除数组中的第一个元素并返回它。如果数组为空,则返回 undefined,并且不修改数组。
process(todo.shift());
if (todo.length > 0) {
// 将当前正在执行的函数本身再次使用定时器
// 本来这里可以直接使用 arguments.callee 的,但是严格模式下用不了,所以这里通过调用这个 fun 来代替
// 注意:arguments 该对象代表正在执行的函数和调用它的函数的参数,而 caller 返回一个对函数的引用,该函数调用了当前函数。
setTimeout(fun, 25);
} else {
callback(items);
}
}, 25);
}

2、根据运行时间分配

例如运行一个千万级别的运算总任务,不直接确定分配为多少个子任务,或者分配的颗粒度比较小,在每一个或几个计算完成后,查看此段运算消耗的时间,如果时间小于某个临界值,比如 10ms,那么就继续进行运算,否则就暂停,等到下一个轮询再进行进行

function timedProcessArray(items, process, callback) {
var todo = items.concat();

setTimeout(function(){
// 开始计时
var start = +new Date();
// 如果单个数据处理时间小于 50ms ,则无需分解任务
do {
process(todo.shift());
} while (todo.length && (+new Date() - start < 50));

if(todo.length > 0) {
setTimeout(arguments.callee, 25);
} else {
callback(items);
}
});

}

优点是避免了第一种情况出现的问题,缺点是多出了一个时间比较的运算,额外的运算过程也可能影响到性能

在 TypeScript 中使用

export const timedProcessArray = <T>(items: Array<T>, process: (item: T | undefined) => void, callback: (items: Array<T>) => void): void => {
const todo = items.concat();

setTimeout(function fun() {
// 开始计时
const start = +new Date(); // JavaScript 中可以在某个元素前使用 ‘+’ 号,这个操作是将该元素转换成 Number 类型
// 如果单个数据处理时间小于 50ms ,则无需分解任务
do {
process(todo.shift());
} while (todo.length && +new Date() - start < 50);

if (todo.length > 0) {
setTimeout(fun, 25);
} else {
callback(items);
}
});
};

首次渲染时的优化

上面那节使用例子:

当一次渲染的图形过多时,将一次渲染分成多次渲染,每次渲染时间增加几毫秒的间隔,这时候就不会卡顿:

这种方案虽然会增加总的渲染时长,但是可以降低页面的卡顿感,对所有图形进行整体更新时也可以使用这个方案,但是进行交互时这种方案会带来一定的延迟。